原文地址

在 Java 中,每一个对象都有一个非常容易理解的 hashCode 方法但仍有很多时候会被忘记或是误用。以下是需要牢记于心并以此避免一些常见的坑的三件事。

一个对象的哈希值允许算法和数据结构把对象也放到格子里。就像是。打印机把所有的 “A” 类型放到 “A” 的格子里。之后它只需要在这个格子里找 “A”。这个简易的系统让它在寻找该类型时比在一个无序的抽屉里搜索要快很多。这也是基于 hash 的集合的灵感来源,类似于 HashMap 和 HashSet。

对于那些用了基于 hash 值的集合以及基于 hash codes 的其他算法的类能够正常执行,所有 hashCode 方法的实现都应该遵循一个简单的约定。

hashCode 的约定

hashCode 方法的 JavaDoc 文档里有对该约定的描述,大致能够说明这个约定。

在同一个进程中,相等的两个对象一定拥有同一个哈希值

注意这并不是在暗示以下两个错误的概念

  • 不等的对象一定拥有不同的哈希值 - 错!
  • 拥有相同哈希值的对象不一定相等 - 错!

约定允许不等的两个对象拥有相同的哈希值。例如上图中 “A” 和 “µ” 两个对象。在数学上,从对象到哈希值的映射不一定要单映射或是双映射。这很明显是因为不同对象的数量通常要比可能出现的所有的哈希值要多(2^32).

Edit 在早起的版本中,我错误地以为哈希值的映射一定是单映射,并且一定不是双映射(明显是错的)。多谢 Lucian 的指正。

这个约定直接引出了第一个规则:

1. 只要你覆写了 equals,一定要覆写 hashCode

如果你没这么做,你将会被有问题的对象搞死。为什么呢?一个对象的 hasCode 方法必须使用同一个字段来作为它的对比依据。在覆写 equals 方法之后,你声明了一些对象和另一些对象相等,但是原生的 hashCode 方法认为所有的对象都是不同的。因此你将会得到一些哈希值不同但相等的对象。例如,在一个 HashMap 调用 contains() 方法,即使你把对象加进去了,也会返回 false。(这里指的应该是已经加入一个 object 了,但是在调用 contains 的时候,使用的是另一个你认为相等[equals 返回 true] 的对象——译者注)

如何写出一个好的 hashCode 方法已经超出了本文的范围,这点在 Joshua Bloch 的一本非常受欢迎的书 Effective Java 里有非常完美的讲解,一本开发者不能错过的书籍。

[在你的项目中需要一些专业的建议?我们的 开发者支持 可以帮你解决问题。| 或者在这本书软件工艺 中学习如何写出整洁的代码]

出于安全起见,Eclipse IDE 将 equalshashCode 作为成对的选项出现:Source > Generate hashCode() and equals()……

出于自我保护,你可以设置 Eclipse 来检测是否违反这个规则并且在 classes 上做出错误提示。很不幸,这个选项默认是 “Ignore” 的:Preferences > Java > Compiler > Errors/Warnings。然后在搜索中使用 “hashCode” 进行快速查找:

image.png

更新 equalsverifier 用来检测 hashCode 和 equals 的这个约定很不错,你应该考虑在你的单元测试中使用。

哈希碰撞

两个不同的对象拥有同一个哈希值。我们称之为碰撞。一个碰撞并不严重,这仅仅意味着在一个单桶中拥有多个对象,因此 HashMap 查询需要查对比多次才能找到正确的对象。大量的碰撞会降低系统性能,但并不会产生错误的结果。

但如果你在一个对象上唯一的判断依据上搞错了哈希值,譬如使用它作为 Map 中的一个 key,你有时候还是会取到一个错误的对象。因为即使碰撞不常见,但仍是不可避免的。譬如字符串 “Aa” 和 “BB” 就会产生相同的哈希值:2112。因此:

2. 不要将哈希值作为 Map 的 key

不像打印机的打印例子,在 Java 中共有 4,294,967,296 个格子(2^32 个整型值)。拥有 40 亿个坑位,碰撞看起来极不可能?
结果证明并不是。令人意外的是数学上的碰撞:想象一下一个房间里的 23 个人,你如何来估算出他们之中两个同生日的男性的概率呢?挺低的,因为一年里有 365 天?实际上,概率大概是 50%!因为如果有 50 个人的话,这将是一个绝对事件。这个现象被称作 生日悖论。转换为哈希值,这将意味着如果存在 77136 个不同的对象,将在你的 hashCode 方法中有 100% 的概率出现碰撞。它们将均匀地分布到每一个桶中(桶类似于上文中的格子,也就是整型值——译者注)

例子

Enron email dataset 包含 520924 条邮件,计算邮件内容字符串的哈希值。我发现有 50 对(也可能是三个一组)的不同邮件拥有相同的哈希值。对于 50 万个字符串,这已经是个不错的结果了。但是:如果你拥有更多的数据,碰撞仍会发生。如果你使用哈希值作为key,你将无法立即发现错误,但仍有少部分人会拿到错误的邮件。

哈希值可以被修改

最后,hashCode 约定中有一个比较重要的细节堪称意外:哈希值在不同的执行过程中不能保证计算出相同的值。以下是 Java 文档

在 Java 应用的运行中,对同一个对象上执行该方法多次时,hashCode 方法始终会返回同一个整型数,在对象的 equals 对比中并未提供相关信息,该整型数在同一个程序的多次执行时不需要保持不变。

这并不常见,实际上,一些类库的类中经常会明确写一些特定的算法用以计算哈希值(譬如 String)。对于这些类,哈希值总是相同的。但当大部分 hashCode 的实现都提供了一个稳定的值,此时坚决不能信赖它。因为这篇文章 指出,在不同的进程中一些 Java 库竟然会返回不同的哈希值而且因此会是人们陷入迷惑。Google 的协议缓冲就是个例子。

以此,你不应该在分布式应用中使用哈希值。一个远程的对象可能拥有不同于本地的哈希值,即使它们完全相等。

3. 在分布式应用中不要使用 hashCode

此外,你应该意识到 hashCode 方法的实现可能会从一个版本更迭到另一个。因此你的代码不应该依赖于一些特定的哈希值。例如,你不应该使用哈希值来持久化状态。下次你运行程序,同一个对象的哈希值就可能不同。

最好的建议是: 完全不要用 hashCode, 除非你在写一些基于哈希的算法。

另一个选择:SHA1

正如你所知,密文的哈希值类似于 SHA1 有时候用来唯一标识对象(Git 就这样)。这也是不安全的?不,SHA1 使用 160 位的 key,这使得碰撞不可能发生。即使是巨量的对象,在这个空间里在你程序运行中发生碰撞的概率远低于流行砸到你的电脑。这篇文章 很好的讲述了碰撞的可能性。

关于哈希值还有很多要讲的,但这几个看起来是最重要的几点。

donation